想把一整個資料夾的圖片一次轉成 JPG/PNG/WebP、順便縮圖/壓縮?
今天做一個純本機、零後端的小工具:
安裝
pip install pillow
程式碼(存成 img_convert_gui.py)
# img_convert_gui.py — Day 19:圖片批量轉檔 GUI(Tkinter + Pillow)
from __future__ import annotations
import threading, os, sys, subprocess
from pathlib import Path
from tkinter import Tk, StringVar, IntVar, BooleanVar, filedialog, messagebox
from tkinter import ttk
from PIL import Image, ImageOps
SUPPORT = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tif", ".tiff"}
def parse_patterns(text: str) -> list[str]:
# 以逗號或空白分隔;空字串代表全部
toks = [t.strip() for t in text.replace(",", " ").split() if t.strip()]
return toks or ["*"]
def list_images(src: Path, recursive: bool, patterns: list[str]) -> list[Path]:
files: list[Path] = []
for pat in patterns:
glob = src.rglob if recursive else src.glob
for p in glob(pat):
if p.is_file() and p.suffix.lower() in SUPPORT:
files.append(p)
# 去重
uniq, seen = [], set()
for p in files:
rp = p.resolve()
if rp not in seen:
uniq.append(p)
seen.add(rp)
return uniq
def resize_keep_ratio(img: Image.Image, max_side: int) -> Image.Image:
if not max_side or max_side <= 0:
return img
w, h = img.size
long_side = max(w, h)
if long_side <= max_side:
return img
scale = max_side / long_side
new_size = (int(w * scale), int(h * scale))
return img.resize(new_size, Image.LANCZOS)
def rgb_on_white(im: Image.Image) -> Image.Image:
"""把有透明的圖疊在白底上再轉 RGB(避免轉 JPG 變黑邊/鋸齒)。"""
if im.mode in ("RGBA", "LA") or (im.mode == "P" and "transparency" in im.info):
rgba = im.convert("RGBA")
bg = Image.new("RGB", rgba.size, (255, 255, 255))
bg.paste(rgba, mask=rgba.split()[-1])
return bg
return im.convert("RGB")
def open_folder(path: Path):
try:
if sys.platform.startswith("win"): os.startfile(str(path))
elif sys.platform == "darwin": subprocess.run(["open", str(path)])
else: subprocess.run(["xdg-open", str(path)])
except Exception:
pass
class App:
def __init__(self):
self.root = Tk()
self.root.title("圖片批量轉檔 (Day 19)")
self.root.geometry("760x420")
self.src = StringVar()
self.dst = StringVar()
self.patterns = StringVar(value="*.jpg, *.jpeg, *.png, *.webp")
self.recursive = BooleanVar(value=True)
self.preserve_tree = BooleanVar(value=True)
self.max_side = IntVar(value=1280)
self.fmt = StringVar(value="jpg") # keep / jpg / png / webp
self.quality = IntVar(value=85)
self.keep_exif = BooleanVar(value=True)
self.rename = BooleanVar(value=False)
self.status = StringVar(value="等待開始…")
self.prog = None
self.build_ui()
def build_ui(self):
pad = {"padx": 8, "pady": 6}
row = ttk.Frame(self.root); row.grid(row=0, column=0, sticky="we", **pad); row.columnconfigure(1, weight=1)
ttk.Label(row, text="來源資料夾").grid(row=0, column=0, sticky="w")
ttk.Entry(row, textvariable=self.src).grid(row=0, column=1, sticky="we")
ttk.Button(row, text="選擇…", command=self.pick_src).grid(row=0, column=2)
row = ttk.Frame(self.root); row.grid(row=1, column=0, sticky="we", **pad); row.columnconfigure(1, weight=1)
ttk.Label(row, text="輸出資料夾").grid(row=0, column=0, sticky="w")
ttk.Entry(row, textvariable=self.dst).grid(row=0, column=1, sticky="we")
ttk.Button(row, text="選擇…", command=self.pick_dst).grid(row=0, column=2)
row = ttk.Frame(self.root); row.grid(row=2, column=0, sticky="we", **pad); row.columnconfigure(1, weight=1)
ttk.Label(row, text="過濾 patterns").grid(row=0, column=0, sticky="w")
ttk.Entry(row, textvariable=self.patterns).grid(row=0, column=1, sticky="we")
ttk.Checkbutton(row, text="包含子資料夾 (recursive)", variable=self.recursive).grid(row=0, column=2)
ttk.Checkbutton(row, text="保留原子資料夾結構", variable=self.preserve_tree).grid(row=0, column=3, padx=(8,0))
row = ttk.Frame(self.root); row.grid(row=3, column=0, sticky="we", **pad)
ttk.Label(row, text="最長邊(px,0=不縮)").grid(row=0, column=0)
ttk.Spinbox(row, from_=0, to=8000, textvariable=self.max_side, width=8).grid(row=0, column=1, padx=(4,12))
ttk.Label(row, text="輸出格式").grid(row=0, column=2)
ttk.Combobox(row, values=["keep","jpg","png","webp"], textvariable=self.fmt, width=8, state="readonly"
).grid(row=0, column=3, padx=(4,12))
ttk.Label(row, text="品質(1-100)").grid(row=0, column=4)
ttk.Spinbox(row, from_=1, to=100, textvariable=self.quality, width=6).grid(row=0, column=5, padx=(4,12))
ttk.Checkbutton(row, text="保留 EXIF(JPG)", variable=self.keep_exif).grid(row=0, column=6)
ttk.Checkbutton(row, text="檔名加 _converted", variable=self.rename).grid(row=0, column=7, padx=(8,0))
row = ttk.Frame(self.root); row.grid(row=4, column=0, sticky="we", **pad)
ttk.Button(row, text="開始轉檔", command=self.start).grid(row=0, column=0, padx=(0,8))
ttk.Button(row, text="開啟輸出資料夾", command=self.open_out).grid(row=0, column=1)
row = ttk.Frame(self.root); row.grid(row=5, column=0, sticky="we", **pad); row.columnconfigure(0, weight=1)
self.prog = ttk.Progressbar(row, length=700, mode="determinate", maximum=100)
self.prog.grid(row=0, column=0, sticky="we")
ttk.Label(row, textvariable=self.status).grid(row=1, column=0, sticky="w", pady=(4,0))
def pick_src(self):
d = filedialog.askdirectory(title="選擇來源資料夾")
if d: self.src.set(d)
def pick_dst(self):
d = filedialog.askdirectory(title="選擇輸出資料夾")
if d: self.dst.set(d)
def open_out(self):
dst = Path(self.dst.get().strip() or ".")
open_folder(dst)
def set_status(self, text: str):
self.status.set(text)
def set_progress(self, done: int, total: int):
pct = 0 if total == 0 else int(done * 100 / total)
self.prog["value"] = pct
self.set_status(f"處理中:{done}/{total} ({pct}%)")
def start(self):
src = Path(self.src.get().strip())
dst = Path(self.dst.get().strip())
if not src.exists():
messagebox.showerror("錯誤", "來源資料夾不存在"); return
if not dst.exists():
try: dst.mkdir(parents=True, exist_ok=True)
except Exception as e:
messagebox.showerror("錯誤", f"無法建立輸出資料夾:\n{e}"); return
pats = parse_patterns(self.patterns.get())
recursive = self.recursive.get()
files = list_images(src, recursive, pats)
total = len(files)
self.set_progress(0, total)
if total == 0:
self.set_status("找不到符合的圖片"); return
t = threading.Thread(target=self.worker,
args=(files, src, dst, self.preserve_tree.get(),
self.max_side.get(), self.fmt.get(), self.quality.get(),
self.keep_exif.get(), self.rename.get()),
daemon=True)
t.start()
def worker(self, files: list[Path], src: Path, dst: Path, preserve_tree: bool,
max_side: int, fmt: str, quality: int, keep_exif: bool, rename: bool):
done = 0
for fp in files:
try:
rel = fp.relative_to(src) if preserve_tree else fp.name
if isinstance(rel, Path):
target_dir = (dst / rel.parent)
else:
target_dir = dst
target_dir.mkdir(parents=True, exist_ok=True)
# 決定輸出副檔名
out_fmt = fmt.lower()
if out_fmt == "keep":
ext_out = fp.suffix.lower()
else:
ext_out = ".jpg" if out_fmt in ("jpg", "jpeg") else (".png" if out_fmt == "png" else ".webp")
stem = fp.stem + ("_converted" if rename else "")
out_path = target_dir / f"{stem}{ext_out}"
with Image.open(fp) as im:
# 矯正旋轉
try:
im = ImageOps.exif_transpose(im)
except Exception:
pass
# 等比例縮圖
im_resized = resize_keep_ratio(im, max_side)
save_im = im_resized
save_kwargs = {}
if ext_out in (".jpg", ".jpeg"):
save_im = rgb_on_white(im_resized) # 去 alpha,白底
save_kwargs.update(dict(quality=int(quality), optimize=True, progressive=True))
if keep_exif and getattr(im, "info", {}).get("exif"):
save_kwargs["exif"] = im.info["exif"]
elif ext_out == ".png":
# PNG 無損,最佳化
if save_im.mode == "P":
save_im = save_im.convert("RGBA") # 避免調色盤轉存失真
save_kwargs.update(dict(optimize=True))
elif ext_out == ".webp":
# 有透明就保留 RGBA,JPG 來源也可轉成失真有損 WebP
if save_im.mode not in ("RGB", "RGBA"):
save_im = save_im.convert("RGBA" if "A" in save_im.getbands() else "RGB")
save_kwargs.update(dict(quality=int(quality), method=4))
# 嘗試帶 EXIF(視 Pillow/來源支援度)
if getattr(im, "info", {}).get("exif"):
save_kwargs["exif"] = im.info["exif"]
save_im.save(out_path, **save_kwargs)
except Exception as e:
# 失敗就略過
print(f"[跳過] {fp} -> {e}")
done += 1
self.root.after(0, self.set_progress, done, len(files))
self.root.after(0, self.set_status, f"完成!已處理 {done} 張,輸出於:{dst}")
def run(self):
self.root.mainloop()
if __name__ == "__main__":
App().run()
怎麼用
實作:
常見問題
今日小結
做出一個可視化的圖片批量轉檔器:JPG/PNG/WebP、縮圖/壓縮、EXIF、保留子資料夾、進度條。